iT邦幫忙

2024 iThome 鐵人賽

DAY 14
1
Modern Web

Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器系列 第 14

Day 14: Pinia 與 Vue Router 的結合:實現高級應用狀態的導航守衛

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20240921/20117461LEw6op44sX.jpg

介紹

在現代 Vue.js 應用程序中,Pinia 和 Vue Router 的結合使用為我們提供了強大的狀態管理和路由控制能力。今天,我們將深入探討如何將這兩個工具整合,特別是如何利用 Pinia plugins 來增強路由功能,並實現基於複雜應用狀態的導航守衛。我們還會特別關注如何將這些概念與 Day10 RBACABAC 的概念結合,實現更加精細和靈活的權限控制。本文比較偏向把先前的概念整合,並和在一起,擔心讀者消化不良,但每個主題卻深入展示,今天就以一個簡短的整合,因此程式碼上會較先前有些簡化。

Pinia Plugins 與 Vue Router 的整合

首先,讓我們創建一個 Pinia plugin,將 router 注入到每個 store 中:

import { PiniaPluginContext } from 'pinia'
import { Router } from 'vue-router'

export function routerPlugin(router: Router) {
  return ({ store }: PiniaPluginContext) => {
    store.router = router
  }
}

在主應用文件中使用這個 plugin:
提醒:這個方法有在 Day11 的末尾有提到過,那裡有更進階的應用,這裡是簡單的展示。

import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import { routerPlugin } from './plugins/routerPlugin'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    // 你的路由配置
  ]
})

const pinia = createPinia()
pinia.use(routerPlugin(router))

// 在你的 app 創建中使用 pinia 和 router

進階應用場景

1. 基於 RBAC 的動態路由和導航守衛

首先,我們定義一個權限 store:

import { computed } from 'vue';
import { PermissionRole } from "../achemas/auth";
import { usePermissionApi } from "../composables/usePermissionApi";
import { definePrivateState } from "./usePrivateState";

export const usePermissionStore = definePrivateState('usePermissionStore', () => {
  return {
    userRole: PermissionRole.None,
    permissions: [] as string[]
  }
}, (privateState) => {
  const { getFakeUserPermissions } = usePermissionApi();

  const fetchUserPermissions = async (): Promise<void> => {
    const response = await getFakeUserPermissions()
    privateState.userRole = response.role;
    privateState.permissions = response.permissions;
  };

  const hasPermission = (permission: string): boolean => {
    return privateState.permissions.includes(permission)
  };

  return {
    userRole: computed(() => privateState.userRole),
    permissions: computed(() => privateState.permissions),
    fetchUserPermissions,
    hasPermission,
  }
})

備註:關於 definePrivateState 的使用可以參考 Day11

抓取 api 的部分可以參考

import { PermissionRole, permissionRoleSchema, PermissionRoleSchema } from '../achemas/auth';

export const usePermissionApi = () => {
  const getFakeUserPermissions = async (): Promise<PermissionRoleSchema> => new Promise((resolve, reject) => {
    setTimeout(() => {
      const result: PermissionRoleSchema = {
        role: PermissionRole.User,
        permissions: []
      };
      const validator = permissionRoleSchema.safeParse(result);
      if (!validator.success) {
        reject(new TypeError('zod type error'));
        return;
      }
      resolve(validator.data);
    }, 200);
  });

  return {
    getFakeUserPermissions,
  };
};

export type UsePermissionApi = typeof usePermissionApi;

型別的部分:

import * as zod from 'zod';

export enum PermissionRole {
  None = '',
  Admin = 'admin',
  Manager = 'manager',
  User = 'user',
}

export const permissionRoleSchema = zod.object({
  role: zod.nativeEnum(PermissionRole).refine(val => val !== PermissionRole.None),
  permissions: zod.string().array(),
});

export type PermissionRoleSchema = zod.infer<typeof permissionRoleSchema>;

然後,我們可以實現基於角色的動態路由生成:

import { usePermissionStore } from '@/stores/usePermissionStore'

const permissionStore = usePermissionStore();
const { fetchUserPermissions, hasPermission } = permissionStore;
const { userRole, permissions } = storeToRefs(permissionStore);

await fetchUserPermissions();

const roleRoutes: Record<Exclude<PermissionRole, PermissionRole.None>, RouteRecordRaw[]> = {
  [PermissionRole.Admin]: [
    { path: '/admin', component: () => import('../pages/Admin.vue') },
    { path: '/users', component: () => import('../pages/Users.vue') }
  ],
  [PermissionRole.Manager]: [
    { path: '/dashboard', component: () => import('../pages/ManagerDashboard.vue') },
    { path: '/reports', component: () => import('../pages/Report.vue') }
  ],
  [PermissionRole.User]: [
    { path: '/profile', component: () => import('../pages/UserProfile.vue') },
    { path: '/orders', component: () => import('../pages/Order.vue') }
  ],
}

const currentRoleRoutes = userRole.value === PermissionRole.None ? [] : roleRoutes[userRole.value];

const routes: RouteRecordRaw[] = [ 
  { 
    path: '/', 
    component: () => import('../pages/Dashboard.vue'),
    children: [
      {
        path: '/',
        name: RoutesStatus.Home,
        component: () => import('../pages/Home.vue'),
      },
    ],
  },
  { 
    path: '/:catchAll(.*)',
    name: RoutesStatus.NotFound,
    component: () => import('../pages/NotFound.vue') 
  },
  ...currentRoleRoutes // 新添加權限的路由
] 

實現導航守衛:

const isString = (input: unknown): input is string  => {
  return typeof input === 'string';
};

router.beforeEach((to) => {
  const permissionTag = to.meta.requiredPermission;
  if (!permissionTag) return true;
  if (!isString(permissionTag) || !hasPermission(permissionTag)) return { name: RoutesStatus.Home };
  return true; 
});

2. 基於 ABAC 的複雜權限控制

ABAC 允許我們基於多個屬性進行更

精細的權限控制。讓我們擴展我們的 PermissionStore 來支援 ABAC:

import { computed } from 'vue';
import { PolicyAttributesSchema, PolicySchema } from "../achemas/auth";
import { usePermissionApi } from "../composables/usePermissionApi";
import { definePrivateState } from "./usePrivateState";

export const usePermissionStore = definePrivateState('usePermissionStore', () => {
  return {
    userAttributes: [] as PolicyAttributesSchema[],
    policies: [] as PolicySchema[]
  }
}, (privateState) => {
  const { getFakePolicies, getFakeAttributes } = usePermissionApi();

  const fetchPolicies = async (): Promise<void> => {
    const policies = await getFakePolicies();
    privateState.policies = policies;
  };

  const fetchUserAttributes = async (): Promise<void> => {
    const policies = await getFakePolicies();
    privateState.policies = policies;
  };

  const evaluateAccess = (resource: string, action: string): boolean => {
    return privateState.policies.some(policy => {
      return policy.resource === resource &&
      policy.action.some((item) => item === action) &&
      policy.attributes.every(item => {
        return privateState.userAttributes.some(attr => {
          return item === attr;
        })
      })
    })
  };
  
  return {
    userAttributes: computed(() => privateState.userAttributes),
    policies: computed(() => privateState.policies),
    fetchPolicies,
    fetchUserAttributes,
    evaluateAccess
  }
});

型別的部分

export enum PolicyAction {
  Create = 'create',
  Edit = 'edit',
  Delete = 'delete',
  Query = 'query',
}

export const policyActionSchema = zod.nativeEnum(PolicyAction);
export type PolicyActionSchema = zod.infer<typeof policyActionSchema>;

export const policyAttributesSchema = zod.string();
export type PolicyAttributesSchema = zod.infer<typeof policyAttributesSchema>;

export const policySchema = zod.object({
  resource: zod.string(),
  action: zod.nativeEnum(PolicyAction).array(),
  attributes: policyAttributesSchema.array(),
});

export type PolicySchema = zod.infer<typeof policySchema>;

製作假的 api 測試

import { PermissionRole, permissionRoleSchema, PermissionRoleSchema, PolicyAction, policyActionSchema, PolicyActionSchema, policyAttributesSchema, PolicyAttributesSchema, policySchema, PolicySchema } from '../achemas/auth';

export const usePermissionApi = () => {

  const getFakePolicies = async (): Promise<PolicySchema[]> => new Promise((resolve, reject) => {
    setTimeout(() => {
      const result: PolicySchema[] = [{
        resource: 'testSample',
        action: [PolicyAction.Query, PolicyAction.Create],
        attributes: ['hello'],
      }];
      const validator = policySchema.array().safeParse(result);
      if (!validator.success) {
        reject(new TypeError('zod type error'));
        return;
      }
      resolve(validator.data);
    }, 200);
  });

  const getFakeAttributes = async (): Promise<PolicyAttributesSchema[]> => new Promise((resolve, reject) => {
    setTimeout(() => {
      const result: PolicyAttributesSchema[] = ['hello'];
      const validator = policyAttributesSchema.array().safeParse(result);
      if (!validator.success) {
        reject(new TypeError('zod type error'));
        return;
      }
      resolve(validator.data);
    }, 200);
  });

  return {
    getFakePolicies,
    getFakeAttributes,
  };
};

export type UsePermissionApi = typeof usePermissionApi;

現在,我們可以在路由守衛中使用這個增強版的權限控制:

router.beforeEach(async (to, from, next) => {
  const permissionStore = usePermissionStore()
  
  if (!permissionStore.userAttributes.length) {
    await permissionStore.fetchUserAttributes()
  }
  
  if (!permissionStore.policies.length) {
    await permissionStore.fetchPolicies()
  }
  
  if (!to.meta.requiredAccess) return true;
   const { resource, action } = to.meta.requiredAccess
   if (!permissionStore.evaluateAccess(resource, action)) {
     return { name: RoutesStatus.Home };
   }
   return true;
})

結論

通過結合 Pinia 和 Vue Router,並整合 RBAC 和 ABAC 的概念,我們可以實現一個極其靈活和強大的權限控制系統。這種方法允許我們基於用戶的角色、屬性以及細粒度的策略來控制路由訪問。

這種高級的整合不僅提高了應用的安全性,還提供了極大的靈活性,使我們能夠根據不同的場景和需求輕鬆調整訪問控制邏輯。通過使用 Pinia store 來管理權限狀態,我們還能在整個應用中方便地訪問和使用這些權限信息,不僅用於路由控制,還可用於 UI 渲染和其他業務邏輯。

在實際應用中,記得要根據你的具體需求和安全要求來調整這些策略。同時,也要考慮性能影響,可能需要實現緩存機制來優化頻繁的權限檢查。


上一篇
Day 13: 使用 @vueuse/core 和自定義 Composables 提升 Vue 3 開發效率
下一篇
Day 15: 使用 TypeScript 和 Zod 進行後端 API 數據驗證
系列文
Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言